리액트 토스트 커스텀 훅 만들기
사용자의 인터렉션에 대한 반응을 주기 위해 하단에 작은 알림을 띄어주게 되는데 이러한 요소를 토스트라고 한다.
이러한 토스트를 쉽게 사용할 수 있도록 커스텀 훅으로 빼면 토스트 컴포넌트를 직접 jsx에 추가하지 않아도 되고, 다른 커스텀 훅에서도 쉽게 사용할 수 있다.
처음으로 만들었던 방법은 매번 추가로 토스트 컴포넌트를 직접 불러와야 해서 프로젝트의 팀원에게 불편하다는 의견을 들었고 다른 커스텀 훅 안에서 사용하기에도 불편했다.
그래서 좀 더 나은 토스트 컴포넌트 구성을 찾는 중 shadcn/ui의 토스트 컴포넌트가 참고가 될만해서 참고해 보았다.
shadcn/ui의 코드는 https://ui.shadcn.com/docs/components/toast 을 참고하면 된다.
본 글은 위의 코드를 심플하게 만드는 내용이라 보면 된다.
구성
이번 토스트는 화면에 하나의 토스트만 보이며, 일정 시간 후에 자동으로 사라지는 형태이다.
추가로 여러 토스트를 표시할 수 있게 확장할 수 있는 상태로 두었기에 약간의 변경을 통해 여러 개의 토스트를 표시, 혹은 입력을 받은 후에 사라지는 등의 형태를 만들 수 있을 것이다.
토스트 컴포넌트의 스타일링보다는 토스트 컴포넌트를 어떻게 쉽게 사용할지에 대해 주로 다룬다.
Toaster
토스트를 매번 사용할 때마다 토스트 컴포넌트를 불러와 페이지 등에 추가하기보다 상위 컴포넌트에서 한 번만 추가하여 커스텀 훅으로 매우 편리하다.
// layout.tsx export default async function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="ko"> <head> </head> <body> <div>{children}</div> <Toaster /> </body> </html> ) }
위 코드처럼 Toaster
라는 컴포넌트가 한 번만 추가하면 되는데 그러면 Toaster
의 코드를 보자.
// Toaster.tsx 'use client'; import { useToast } from './useToast'; import { Toast } from './Toast'; const Toaster = () => { const { toasts } = useToast(); return ( <> {toasts.map((toast) => { return ( <Toast key={toast.id} show={toast.show}> {toast.message} </Toast> ); })} </> ); }; export default Toaster;
Toaster
컴포넌트 또한 useToast
커스텀 훅을 부르고 있다. 해당 훅에서 토스트들을 받아와 그려주는 역할만 하게 되는 간단한 컴포넌트이다.
Toast
컴포넌트의 스타일 같은 경우 position: fixed
, left: 50%
, transform: translateX(-50%)
가 주요하게 하단에 고정하여 중앙으로 정렬하는 방법이고 이외의 여러개의 토스트를 보여주기, 애니메이션에 관한 스타일링은 이 블로그에서 다루지는 않는다.
다음으로는 실제로 토스트들을 관리하는 useToast
코드를 확인해 보자.
useToast
// useToast.tsx import { useEffect, useState } from 'react'; import { Toast } from './Toast'; const TOAST_REMOVE_DELAY = 3000; interface Toast { id: string; message: string; show: boolean; } let toasts: Toast[] = []; let count = 0; const genId = () => { count = (count + 1) % Number.MAX_SAFE_INTEGER; return count.toString(); }; const timeouts = new Map<string, ReturnType<typeof setTimeout>>(); const listeners: Array<(toasts: Toast[]) => void> = []; const toast = (message: string) => { const id = genId(); listeners.forEach((listener) => { if (!toasts.find((toast) => toast.id === id)) { toasts = [...toasts, { id, message, show: true }]; } listener(toasts); const timeout = setTimeout(() => { toasts = toasts.map((toast) => { return toast.id === id ? { ...toast, show: false } : toast; }); listener(toasts); const maxTimeoutId = !!timeouts.size && [...timeouts.entries()]?.reduce((a, b) => (b[1] > a[1] ? b : a))[0]; if (maxTimeoutId === id) { toasts = []; timeouts.clear(); } }, TOAST_REMOVE_DELAY); timeouts.set(id, timeout); }); }; export const useToast = () => { const [state, setState] = useState(toasts); useEffect(() => { listeners.push(setState); return () => { const index = listeners.indexOf(setState); if (index > -1) { listeners.splice(index, 1); } }; }, []); return { toast, toasts: state, }; }
코드를 보면 알다시피 토스트들을 리액트의 상태로 관리하고 있지 않다. 꼭 상태로 관리해야 하는 값이 아니므로 리액트의 상태로 관리하지 않아도 된다.
다른 함수들에 앞서 useToast
부터 더 살펴보자.
export const useToast = () => { const [state, setState] = useState(toasts); useEffect(() => { listeners.push(setState); return () => { const index = listeners.indexOf(setState); if (index > -1) { listeners.splice(index, 1); } }; }, []); return { toast, toasts: state, }; }
토스트들의 값을 초깃값으로 사용하는 상태를 가지고 있지만, 훅에서 직접 상태를 변경시키지 않는다. 나중에 상태가 변경되어야 하면 setState
를 통해 리렌더링이 일어날 수 있도록 listeners
라는 배열에 넣어주었다.
이후 useEffect
의 클린업에서는 listeners
가 커지는 것을 방지하기 위해 개수를 초기화시키고 있다.
useToast
가 리턴하는 값은 toast
와 toasts
인데 toast
는 토스트를 보이게하는 함수고, toasts
는 useToast
의 state
를 이름을 변경해서 내려준 것으로, 앞에서 본 Toaster
가 표시하는 토스트들을 가지는 값이다. listeners
에 저장한 setState
들이 useToast
의 상태를 변경하고 변경되면 Toaster
에서 새로운 토스트들이 표시되는 흐름이다.
다음으로는 toast
함수를 봐보자.
const toast = (message: string) => { const id = genId(); listeners.forEach((listener) => { if (!toasts.find((toast) => toast.id === id)) { toasts = [...toasts, { id, message, show: true }]; } listener(toasts); const timeout = setTimeout(() => { toasts = toasts.map((toast) => { return toast.id === id ? { ...toast, show: false } : toast; }); listener(toasts); const maxTimeoutId = !!timeouts.size && [...timeouts.entries()]?.reduce((a, b) => (b[1] > a[1] ? b : a))[0]; if (maxTimeoutId === id) { toasts = []; timeouts.clear(); } }, TOAST_REMOVE_DELAY); timeouts.set(id, timeout); }); };
toast
는 호출될 때 메시지를 받는다. 이후 유니크한 아이디를 생성하고 toasts
변수에 저장하고 listeners
안에 있던 listener (listeners안의 setState)
를 통해 리렌더링을 일으켜 토스트를 표시한다.
토스트는 일정 시간 후에 사라져야 하므로 setTimeout
을 통해 시간이 지나면 해당 토스트의 show
프로퍼티를 false
로 만들어 보이지 않게 한다. 이후에도 listener
를 토스트를 보이지 않게 한다.
이후에는 토스트를 가지고 있는 배열과, setTimeout
의 아이디들을 초기화해주는데 제일 마지막에 발생한 setTimeout
에서 초기화시켜주면 된다.
실제 사용
사용하는 경우는 아래처럼 사용하면 된다.
const { toast } = useToast(); const handleSave = () => { toast('저장되었습니다'); } const handleConfirm = () => { toast('확인되었습니다'); } return ( <div> <button onClick={handleSave}>저장</button> <button onClick={handleConfirm}>확인</button> </div> )
토스트를 사용하는 쪽에서는 커스텀 훅을 통해 toast
함수를 불러와 사용하기만 되고 Toast
를 직접 보여주는 컴포넌트에 대해서는 직접 관리하지 않기 때문에 유용하다.
마무리
변경 후 토스트를 사용하는 프로젝트의 다른 팀원들은 커스텀 훅을 부르고 결과 값 중에 토스트를 표시하는 함수를 통해 토스트만 표시하게 되어 매우 편리해졌다는 의견을 들었다!
이번 구현에서 배운 점 하나는 모든 것을 리액트의 상태로 관리할 필요가 없다는 점이다. 전역으로 관리돼야 하는 상태는 꼭 리액트의 상태로 관리되지 않아도 된다는 것을 다시 한번 알게 된 것 같다. 상태가 바뀌는 경우 자동으로 리렌더링이 일어나지만 상태가 아닌 경우 의도적으로 setState
를 불러 렌더시키는 방식이 특이했던 것 같다.
여러 개의 토스트를 동시에 보여주거나 애니메이션등 아직 개선해야할 점이 많지만, 간단한 토스트라면 위와 같은 방법도 좋다고 생각한다.